Flutter 使用 beamer 实现复杂路由跳转

最近,我准备使用 Flutter Web 搭建一个内部平台,路由场景比较复杂。之前偶然了解到 beamer 这个路由框架比较强大,因此打算一试。

在 Flutter 社区中有许多强大的路由框架,为何选择 beamer 而不选其它更知名库呢?坦白说,我对其它库了解不多。选择 beamer 只是我恰好看到它,且恰好能满足我的需求。这个问题,只能等未来对其它库也了解后,才能给出客观的回答。

我将这次入门的 Demo 开源出来,供大家交流参考:maxiee/maxiee_flutter_beamer_demo

Hello world

一个最简单的 Hello world 如下:

class MyApp extends StatelessWidget {
  final _routerDelegate = BeamerDelegate(
      locationBuilder: RoutesLocationBuilder(routes: {
    '/': (context, state, data) => const HomePage(),
  }));

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: BeamerParser(),
      routerDelegate: _routerDelegate,
    );
  }
}

其中:

整体流程

引用官放文档的整体流程图:

Pasted image 20240102155551.png

在 App 中,通过 URL 来指引页面。

一个路径,通过 BramerParser 解析后,映射到 BeamLocation。BeamLocation 是 beamer 中的核心概念,表示一个或多个页面堆栈的状态

通过 BeamLocation 构建出目标 Page,并通过 Navigator 完成最终跳转。

页面跳转 & URL 传参

跳转

// Basic beaming
Beamer.of(context).beamToNamed('/books/2');

// Beaming with an extension method on BuildContext
context.beamToNamed('/books/2');

// Beaming with additional data that persist 
// throughout navigation within the same BeamLocation
context.beamToNamed('/book/2', data: MyObject());

其中:

参数解析

解析方法:

final routerDelegate = BeamerDelegate(
  locationBuilder: RoutesLocationBuilder(
    routes: {
      // ...
      '/books/:bookId': (context, state, data) {
        // Take the path parameter of interest from BeamState
        final bookId = state.pathParameters['bookId']!;
        // Collect arbitrary data that persists throughout navigation
        final info = (data as MyObject).info;
        // Use BeamPage to define custom behavior
        return BeamPage(
          key: ValueKey('book-$bookId'),
          title: 'A Book #$bookId',
          popToNamed: '/',
          type: BeamPageType.scaleTransition,
          child: BookDetailsScreen(bookId, info),
        );
      }
    },
  ),
);

其中:BeamPage 用于对路由过程进行高级声明,比如 Key、返回页面、过场等。

也可以在页面内解析页面入参:

@override
Widget build(BuildContext context) {
  final beamState = Beamer.of(context).currentBeamLocation.state as BeamState;
  final bookId = beamState.pathParameters['bookId'];
  ...
}

返回

App 页面返回逻辑不止一种,beamer 支持两种返回模式:

  1. 向上返回(从堆栈中弹出页面)
  2. 逆时针返回(返回到前一个状态)

向上导航是指导航回当前页面堆栈中的前一个页面。这通常被称为“弹出”(pop),通过 Navigator 的 pop/maybePop 方法实现。如果没有指定其他操作,默认的应用栏(AppBar)的返回按钮将会调用这个方法。代码示例:

Navigator.of(context).maybePop();

逆时针导航是指返回到之前所在的任何页面。在深层链接的情况下(例如,从/authors/3来到/books/2,而不是从/books),这种返回并不同于普通的弹出操作。Beamer库会保持一个导航历史在 beamingHistory 中,因此可以逆向时间顺序地导航回 beamingHistory 中的前一个条目。这被称为“光束回传”(beaming back)。逆时针导航也是浏览器返回按钮所做的操作,尽管它不是通过 beamBack 实现,而是通过其内部机制。

Beamer.of(context).beamBack();

简而言之,向上返回是在当前页面的历史堆栈中向前一个页面返回,常见于应用内页面导航。而逆时针返回是基于整个导航历史的返回,适用于更复杂的场景,如深层链接或浏览器的历史记录。两者在开发中根据需要选择使用。

对于 Android 返回键,需要通过以下代码加以适配:

MaterialApp.router(
  ...
  routerDelegate: beamerDelegate,
  backButtonDispatcher: BeamerBackButtonDispatcher(
	  delegate: beamerDelegate),
)

页面降级拦截

经常地,在页面跳转时,我们希望进行一些拦截操作。比如有的页面仅允许登陆用户访问,如果用户未登录,应自动降级到登陆页面。在 beamer 中,通过 BeamGuad 能够方便地实现降级逻辑。

比如,实现一个对所有页面的检查,如果用户未登录,跳转到登陆页,代码如下:

final routerDelegate = BeamerDelegate(
  // 添加 guard 降级逻辑
  guards: [
	  BeamGuard(
		  pathPatterns: ['/*'],
		  check: (ctx, location) => //... 判断用户是否登陆
		  beamToNamed: (_, __) => '/login' // 降级页面的路径
	  )
  ],
  locationBuilder: RoutesLocationBuilder(
    routes: {
      // ...
    },
  ),
);

底部导航栏场景

先来一个经典场景练练手:底部导航栏。参考自官方 Example

我在官方 Example 基础上做了一些修改。不同之处在与:官方的底部导航栏 Example 是一个单独工程,进入 bottom_navigation 目录后直接运行。而我的 Demo 中,bottom_navigation 模块的入口页面,作为整个 App 的二级页面。也就是说,其实我使用了嵌套路由的技巧。

因此,在我的版本中,UI 展示上会出现两个导航栏,这不是 beamer 出 bug 了,而是我们有意为之,有利于我们了解 beamer 的底层原理。

接下来,以我的版本进行介绍。对于同样入门 breamer 的朋友,我建议直接运行官方 Example,更加简单直观。

跳转流程

在我的修改版本中,从首页点击按钮,进入 BottomNavigationPage 的入口页。BottomNavigationPage 包含一个嵌套路由,可以通过底部导航栏进行切换。在 BottomNavigationPage 中点击 Books 列表元素,将跳转到 BookDetailScreen,从两套 AppBar 中,可以直观看出嵌套路由。

跳转流程如下:

Flutter bramer1.png

总路由声明

总路由声明,与文章开头一样,在路由表中添加 BottomNavigationPage 的 URL:

class MyApp extends StatelessWidget {
  final _routerDelegate = BeamerDelegate(
      locationBuilder: RoutesLocationBuilder(routes: {
        '/': (context, state, data) => const HomePage(),
        '/bottom_navigation': (context, state, data) => BottomNavigationPage(),
      }));
 // ...

BottomNavigationPage

BottomNavigationPage 页面实现如下:

class BottomNavigationPage extends StatelessWidget {
  BottomNavigationPage({super.key});

  final _beamerKey = GlobalKey<BeamerState>();
  final _routerDelegate = BeamerDelegate(
    locationBuilder: BeamerLocationBuilder(
      beamLocations: [
        BooksLocation(),
        ArticlesLocation(),
      ],
    ),
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Bottom Navigation'),
      ),
      body: Beamer(
        key: _beamerKey,
        routerDelegate: _routerDelegate,
      ),
      bottomNavigationBar: BottomNavigationBarWidget(
        beamerKey: _beamerKey,
      ),
    );
  }
}

在上面代码中,首先创建了一个 Beamer,表示创建了一个嵌套路由。从 Beamer 有自己的 BeamerDelegate 也能够看出,它有自己的 URL 解析规则。

所谓嵌套路由,相当于在页面内内置了一个内部的路由,在页面外界感知不到,只有页面内能感知到。

locationBuilder 的实现与 MyApp 不同,它使用了 BeamLocation 机制,BeamLocation 用于在页面栈内部定义多套独立的页面堆栈结构。

从底部导航栏的截图中,能够直观理解两套堆栈结构的,Books Tab 有自己的列表页和详情页,Articles Tab 有自己的列表页和详情页。两者之间的路由声明通过 BeamLocation 进行解耦,非常直观。

BottomNavigationBarWidget

下面看底部导航栏的实现:

class BottomNavigationBarWidget extends StatefulWidget {
  BottomNavigationBarWidget({required this.beamerKey});

  final GlobalKey<BeamerState> beamerKey;

  @override
  _BottomNavigationBarWidgetState createState() =>
      _BottomNavigationBarWidgetState();
}

class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
  late BeamerDelegate _beamerDelegate;
  int _currentIndex = 0;

  void _setStateListener() => setState(() {});

  @override
  void initState() {
    super.initState();
    _beamerDelegate = widget.beamerKey.currentState!.routerDelegate;
    _beamerDelegate.addListener(_setStateListener);
  }

  @override
  Widget build(BuildContext context) {
    _currentIndex =
        _beamerDelegate.currentBeamLocation is BooksLocation ? 0 : 1;
    return BottomNavigationBar(
      currentIndex: _currentIndex,
      items: [
        BottomNavigationBarItem(label: 'Books', icon: Icon(Icons.book)),
        BottomNavigationBarItem(label: 'Articles', icon: Icon(Icons.article)),
      ],
      onTap: (index) => _beamerDelegate.beamToNamed(
        index == 0 ? '/books' : '/articles',
      ),
    );
  }

  // ...
}

其中:

BooksLocation

以 BooksLocation 为例(ArticlesLocation 实现类似,省略):

class BooksLocation extends BeamLocation<BeamState> {
  @override
  List<String> get pathPatterns => ['/books/:bookId'];

  @override
  List<BeamPage> buildPages(BuildContext context, BeamState state) => [
        BeamPage(
          key: ValueKey('books'),
          title: 'Books',
          type: BeamPageType.noTransition,
          child: BooksScreen(),
        ),
        if (state.pathParameters.containsKey('bookId'))
          BeamPage(
            key: ValueKey('book-${state.pathParameters['bookId']}'),
            title: books.firstWhere((book) =>
                book['id'] == state.pathParameters['bookId'])['title'],
            child: BookDetailsScreen(
              book: books.firstWhere(
                  (book) => book['id'] == state.pathParameters['bookId']),
            ),
          ),
      ];
}

主要实现两个方法:

这里的以声明式构造页面需要尤为重视。在传统的命令式路由中,需要通过调用 push、pop 来添加删除页面。而在声明式路由中,指定层级 URL,在回调中一次性生成所有页面的描述。

BookDetailsScreen

以 BookDetailsScreen 为例,对于内部路由来说,实则是从 /books 跳到 /books/1,这两次过程都会调用 buildPages,前者的 List<BeamPage> 中只有 BooksScreen 一个元素,而后者有 BooksScreen、BookDetailsScreen 这两个元素。

从 BookDetailsScreen 的截图中可以看出,有两个 AppBar:

当然,在实际 App 中不会存在同时出现两个标题栏的情况。当真正遇到嵌套路由场景,需要整合 beamer 的强大功能,打造一套流畅的路由体验。

BeamLocation

Beamer 中最重要的构造是 BeamLocation ,它表示一个或多个页面堆栈的状态。

BeamLocation 的 3 各重要作用:

从上面的例子中可以看出,BeamLocation 适合于将某一类路由归到一起,将不同模块路由相互隔离,这在开发大型 App 时非常有帮助。

更多案例

官方 Example 提供了丰富的场景案例,能够覆盖很多复杂场景。

我建议大家在学习时,直接运行官方 Example,内部是一个个单独工程,每个都跑一跑,基本就有概念了。

我写的 maxiee/maxiee_flutter_beamer_demo 反倒搞得奇奇怪怪,不如官方 Example 直观。

不过,通过写 Demo,亲身接入了一番后,我也对 beamer 有了初步的了解。总之,我的目的已经达到了。

TODO

本文在写作上还有很大的提升空间,直接讲解官方 Example 会更好,maxiee/maxiee_flutter_beamer_demo 搞得太奇怪反而不如不提。未来有缘再完善了。

网络资源


本文作者:Maeiee

本文链接:Flutter 使用 beamer 实现复杂路由跳转

版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!


喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!